Java-多线程(三)-锁(死锁,悲观锁,乐观锁)

线程的锁

在线程同步的时候我们说过,一个对象可以通过Synchronized方法机制来对其进行加锁,使得在同一时刻只能有一个线程对其进行访问,但是解决了同步问题的同时出现了新的问题,那就是死锁,下面来看一下什么是死锁.

死锁

我们知道,当线程A拿到X对象的锁时,线程B也去访问X对象,这时候线程B就会进入阻塞状态,直到线程A释放X对象的锁.

那么死锁是怎么发生的呢,下面举个例子:

有三个线程,线程A,B,C. 加锁对象X.

线程A:拿到X的锁,但是执行需要线程C

线程B:需要拿到X的锁,等待线程A释放,阻塞.

线程C:需要依赖于线程B,等待线程B,一样阻塞.

这样线程A等待C,B等待A,C等待B,就形成了一个完美的闭环, 三个线程连带对象 全部挂掉了.

这就是死锁的产生原因.

死锁产生的条件:

1.互斥条件:任务使用的资源至少有一个是不能共享的.(同步)

2.至少有一个任务它必须有一个资源并且正在等待获取一个当前被别的任务持有的资源,就是手里攥着A对象的锁,然后还需要获取B对象的锁.

3.资源不能被任务抢占,任务必须把资源释放当做普通事件.

4.必须有循环等待,这是,一个任务等待其他任务持有的资源,后者有在等待另外任务所持有的资源,直到最后需要第一个任务的资源.

如果是圆桌,ABCDE 5个人 E的右筷子就是A的左筷子, 如果同时拿取, 那么久变成
A等E放右筷子,B等A放右筷子,C等B,D等C,E等D.的死循环

我们通过一个经典的哲学家就餐的案例来说明上述问题.

问题描述:指定5个科学家在一个圆桌上,这些科学家将花一部分时间进行思考,一部分时间进行吃饭,不共享资源,但是就餐时需要通过使用有限数量的餐具,餐具只有5只筷子,而不是五双,每个哲学家只能拿自己左边和右边的筷子,当同时拿到左右两只筷子的时候,可以进行进餐。如果你左筷子或者右筷子正在被占用,那么你就需要等待,直到该筷子使用完毕。

下面来看代码:

首先是筷子:

package chopsticksExample;

public class chopsticks {

    private boolean taken =false;//设定筷子的状态,false代表无人使用,true代表有人使用

    //拿筷子
    public  synchronized void take()throws InterruptedException{
        while(taken){
            wait();//当筷子有人使用的时候将访问线程挂起.
        }
        taken=true;//当筷子有人使用的时候将状态更改为有人使用.
    }


    //扔筷子
    public synchronized  void drop(){

        taken=false;//将筷子置为无人使用状态

        notifyAll();//唤醒所有等待线程.


    }


}

然后是哲学家:

public class philosopher implements  Runnable{

    private chopsticks left;//左筷子

    private chopsticks right;//右筷子.

    private int id;//哲学家ID

    private int ponder;//思考

    private Random rand=new Random(47);


    //为了方便,思考和吃饭都用这个方法进行代替
    public void pause()throws InterruptedException{

        if(ponder==0) return;
        TimeUnit.MILLISECONDS.sleep(rand.nextInt(ponder*250));//随机产生一个数字作为停顿时间

    }


    public philosopher(chopsticks left, chopsticks right, int id, int ponder) {
        this.left = left;
        this.right = right;
        this.id = id;
        this.ponder = ponder;
    }


    @Override
    public void run() {

        try{
            while(!Thread.currentThread().isInterrupted()){
                //我们规定所有人先拿右筷子再拿左筷子.
                pause();//思考
                System.out.println("思考");
                right.take();//拿右筷子
                left.take();//拿左筷子
                pause();//吃饭
                System.out.println("吃他妈的"+this.toString());
                right.drop();//扔筷子
                left.drop();//扔筷子
            }
        }catch (InterruptedException e){

            System.out.println("rua,不吃了!");
        }

        System.out.println("线程结束");
    }

    @Override
    public String toString() {
        return "philosopher{" +
                "left=" + left +
                ", right=" + right +
                ", id=" + id +
                ", ponder=" + ponder +
                ", rand=" + rand +
                '}';
    }
}

下边是测试代码:

咱们先看不会产生死锁的版本:

public class exampleTest {


    public static void main(String[] args) throws InterruptedException {

这里为了方便,全部使用死数值,没有设定相应变量

        chopsticks[] chopsticks= new chopsticks[5];//创建5根筷子的筷子空间数组
啊
        ExecutorService exec = Executors.newCachedThreadPool();//创建线程池

        for(int i =0; i<5;i++){
            chopsticks[i]=new chopsticks();//循环创建5个筷子对象
        }

        for(int i=0; i<5;i++){//这里的I代表筷子的编号,第一个参数是左手筷子,第二个参数是右手筷子,从0开始,最大I编号为4,代表第五根筷子

            if(i<4) {//当不是最后一根筷子的时候
				
                exec.execute(new philosopher(chopsticks[i], chopsticks[(i + 1) % 5], i, 5));
				//这里的(i+1)%5,是因为上边问题描述中,桌子是一个圆形,最后一个人的右手边的筷子,就相当于第一个人左手边的筷子,顺便防止越界访问。
            }else{
				//当时最后一个人的时候,将其顺序改为
                exec.execute(new philosopher(chopsticks[0],chopsticks[i],i,5));
				//这里面传入的是第一根筷子和最后一根筷子, 代表的位置分别为左手和右手,按正常顺序传入应该是最后一根筷子和第一根筷子,正常的拿筷子顺序应该是 先拿[0],再拿[i].但是这种情况下会发生死锁现象,下边会进行解释.
				由于这里传入顺序的改动,本身的拿筷子逻辑是没有变的,这里就相当于先拿[i],再拿[0] 也就是通过传入顺序调换了左右手的拿取顺序.
            }
        }


        TimeUnit.MILLISECONDS.sleep(5000);//给予执行时间

        exec.shutdownNow();//run会一直循环执行,给予interrupt中断.


    }

}

这里我们来说一下为什么如果全部按照一个顺序拿会发生死锁问题,如果每个人的拿筷子逻辑是,先右手再左手,乍一看是没有问题,但是当最后一个人拿取筷子后,第一个人要拿取左筷子,就会陷入阻塞,这时,第四个人拿了右筷子, 最后一个人的左筷子进行阻塞,以此类推形成死锁.

按图来说:

第五人(最后):先拿1,再拿5

第四人:先拿5,再拿4.

...

...

第一人:先拿2,再拿1.

当所有人同时获取了右手筷子后,开始需求左手筷子, 第五个人会被5筷子卡,第四个人会被4筷子卡,以此类推,第一个人会被第一个筷子卡,形成循环等待,变成死锁.

为什么最后一个人先拿左筷子就不会出现死锁的问题呢?

上边说了死锁的原因之一是循环等待,当其不能闭合成一个循环的时候,自然就不会形成一个死锁.

咱们按上边循环等待的逻辑分析一下,当最后一个人先拿左手筷子的时候,当其拿到时,第四个人肯定在等他放下,这时第三个人的右手筷子必定可以拿取,以此类推, 当第一个拿到右手筷子后,这时所有人手里都有筷子,除了4,这时要去需求左手筷子(相对于最后一个人为右手),也就是编号5的筷子, 这时不管1拿到还是5拿到,都可以进行正常操作,然后唤醒线程进行下一轮争抢,这样就解决了死锁的问题.

下面来看一下产生死锁的版本,只要将其稍作改动:

测试代码:

public class exampleTest {


    public static void main(String[] args) throws InterruptedException {

        chopsticks[] chopsticks= new chopsticks[5];//创建5根筷子

        ExecutorService exec = Executors.newCachedThreadPool();//创建线程池

        for(int i =0; i<5;i++){
            chopsticks[i]=new chopsticks();
        }

        for(int i=0; i<5;i++){

//            if(i<4) {
                exec.execute(new philosopher(chopsticks[i], chopsticks[(i + 1) % 5], i, 5));
//            }else{
//
//                exec.execute(new philosopher(chopsticks[0],chopsticks[i],i,5));
//
//            }
        }


        TimeUnit.MILLISECONDS.sleep(9000);

        exec.shutdownNow();


    }

}

只要将其判断语句进行注释即可, 哲学家类中的pause()方法中的ponder*250参数改为20,或者更小,将其停顿时间减少到极低,就有可能会发生死锁现象.

下边是执行结果

吃他妈的philosopher{left=chopsticksExample.chopsticks@7993a1b3, right=chopsticksExample.chopsticks@e0ab2af, id=1, ponder=5, rand=java.util.Random@4662426f}Thu May 31 15:33:27 CST 2018
思考Thu May 31 15:33:27 CST 2018
吃他妈的philosopher{left=chopsticksExample.chopsticks@e0ab2af, right=chopsticksExample.chopsticks@cd4d1e, id=2, ponder=5, rand=java.util.Random@4f2d9df}Thu May 31 15:33:27 CST 2018
思考Thu May 31 15:33:27 CST 2018
吃他妈的philosopher{left=chopsticksExample.chopsticks@cd4d1e, right=chopsticksExample.chopsticks@1a48a6c5, id=3, ponder=5, rand=java.util.Random@1e773154}Thu May 31 15:33:27 CST 2018
思考Thu May 31 15:33:27 CST 2018
吃他妈的philosopher{left=chopsticksExample.chopsticks@1a48a6c5, right=chopsticksExample.chopsticks@3c913845, id=4, ponder=5, rand=java.util.Random@2d948ab9}Thu May 31 15:33:27 CST 2018
思考Thu May 31 15:33:27 CST 2018
吃他妈的philosopher{left=chopsticksExample.chopsticks@3c913845, right=chopsticksExample.chopsticks@7993a1b3, id=0, ponder=5, rand=java.util.Random@51fb54f}Thu May 31 15:33:27 CST 2018
思考Thu May 31 15:33:27 CST 2018
rua,不吃了!Thu May 31 15:33:35 CST 2018
线程结束
rua,不吃了!Thu May 31 15:33:35 CST 2018
线程结束
rua,不吃了!Thu May 31 15:33:35 CST 2018
线程结束
rua,不吃了!Thu May 31 15:33:35 CST 2018
线程结束
rua,不吃了!Thu May 31 15:33:35 CST 2018
线程结束

这里我打印了当前的系统时间, 我们从秒数可以看出, 在15:33:27的时候发生了死锁, 直到exec发出shutdownNow命令中断了线程才结束,15:33:35其中时间间隔8秒.足以证明发生了死锁.

悲观锁与乐观锁(简单介绍,以后有机会补齐)

众所周知,我们在并发中操作数据处于安全性的考虑,我们要对其进行加锁,但是加锁的程序必然没有不加锁的程序运行要快,这里引入了两个概念,乐观锁和悲观锁.

乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。

乐观并发控制多数用于数据争用不大、冲突较少的环境中,这种环境中,偶尔回滚事务的成本会低于读取数据时锁定数据的成本,因此可以获得比其他并发控制方法更高的吞吐量。

摘自维基百科.

实现机制:

乐观锁,大多是基于数据版本( Version )记录机制实现,通过为数据增加一个版本表示,在数据读出时,将版本号一起读出,在数据进行更新时,将版本号加一,通过版本号来确定数据的完整性.

悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作读某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。

悲观锁在每一次进行数据存取的时候都会进行上锁,这样别的数据在读取的时候就要等待当前程序释放锁,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制进行实现.

posted @ 2018-05-31 15:40  CurryRice  阅读(1601)  评论(0编辑  收藏  举报